﻿// <copyright file="AntiCsrfModule.cs" company="Barry Dorrans">
// Copyright (c) 2008 All Right Reserved, Barry Dorrans
//
// This source is subject to the Microsoft Permissive License.
// Please see the License.txt file for more information.
// All other rights reserved.
//
// THIS CODE AND INFORMATION ARE PROVIDED "AS IS" WITHOUT WARRANTY OF ANY 
// KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A
// PARTICULAR PURPOSE.
//
// </copyright>
// <author>Barry Dorrans / Alex Smolen</author>
// <email>barryd@idunno.org / me@alexsmolen.com</email>
// <date>2008-10-15</date>
// <summary>An HTTP module to add and read CRSF protection cookies.</summary>

namespace Idunno.AntiCsrf
{
    using System;
    using System.Globalization;
    using System.Security.Permissions;
    using System.Text.RegularExpressions;
    using System.Web;
    using System.Web.UI;

    using Configuration;

    /// <summary>
    /// Implements the Anti Cross Site Request Forgery HTTP Module.
    /// </summary>
    [AspNetHostingPermissionAttribute(SecurityAction.InheritanceDemand, Level = AspNetHostingPermissionLevel.Minimal)]
    [AspNetHostingPermissionAttribute(SecurityAction.LinkDemand, Level = AspNetHostingPermissionLevel.Minimal)]
    public class AntiCsrfModule : IHttpModule
    {
        /// <summary>
        /// Contains the key name used for HttpContext storage of the CSRF token.
        /// </summary>
        private const string ContextIndexName = "Idunno.AntiCSRF.AntiCsrfModule.CsrfToken";

        /// <summary>
        /// Disposes of the resources (other than memory) used by the module that implements <see cref="T:System.Web.IHttpModule" />.
        /// </summary>
        public void Dispose()
        {
        }

        /// <summary>
        /// Initializes a module and prepares it to handle requests.
        /// </summary>
        /// <param name="context">
        /// An <see cref="T:System.Web.HttpApplication" /> that provides access to the methods, properties, and events common to all application objects within an ASP.NET application 
        /// </param>
        public void Init(HttpApplication context)
        {
            context.PreSendRequestHeaders += PreSendRequestHeaders;
            context.PreRequestHandlerExecute += PreRequestHandlerExecute;
        }

        /// <summary>
        /// Occurs just before ASP.NET starts executing an event handler (for example, a page or an XML Web service).
        /// </summary>
        /// <param name="source">The event source.</param>
        /// <param name="eventArgs">The <see cref="System.EventArgs"/> instance containing the event data.</param>
        [System.Diagnostics.CodeAnalysis.SuppressMessage(
            "Microsoft.Design", 
            "CA1031:DoNotCatchGeneralExceptionTypes", 
            Justification = "It's simply more secure to be graceful by catching all exceptions on a deserialisation problem.")]
        private static void PreRequestHandlerExecute(object source, EventArgs eventArgs)
        {
            HttpApplication application = (HttpApplication)source;
            HttpContext context = application.Context;

            if (context.Handler != null)
            {
                // Check for the opt-out interface
                ISuppressCsrfCheck attributeOptout = context.Handler as ISuppressCsrfCheck;
                if (attributeOptout != null)
                {
                    return;
                }

                // Check for the opt-out attribute
                if (context.Handler.GetType().GetCustomAttributes(typeof(SuppressCsrfCheckAttribute), true).Length > 0)
                {
                    return;
                }

                Page page = context.Handler as Page;
                if (page != null)
                {
                    // Attach our own PreRender event handler so we can write out the CSRF form field.
                    page.PreRender += PagePreRender;

                    // Attach a Page Load handler to check if this is a GET request masquerading as a postback
                    page.Load += PageLoad;

                    if (context.Request.HttpMethod.Equals("POST", StringComparison.Ordinal))
                    {
                        if (context.Request != null)
                        {
                            HttpCookie csrfCookie = context.Request.Cookies[CsrfSettings.Settings.CookieName];
                            string csrfFormField = context.Request.Form[CsrfSettings.Settings.FormFieldName];

                            if (string.IsNullOrEmpty(csrfFormField) &&
                                (csrfCookie == null || string.IsNullOrEmpty(csrfCookie.Value)))
                            {
                                RaiseError(new PotentialCsrfException(Properties.Resources.exceptionMessageCookieAndFormFieldMissing), context);
                            }

                            if (csrfCookie == null || string.IsNullOrEmpty(csrfCookie.Value))
                            {
                                RaiseError(new PotentialCsrfException(Properties.Resources.exceptionMessageCookieMissing), context);
                            }

                            if (string.IsNullOrEmpty(csrfFormField))
                            {
                                RaiseError(new PotentialCsrfException(Properties.Resources.exceptionMessageFormFieldMissing), context);                                
                            }

                            string tokenField = string.Empty;
                            ObjectStateFormatter stateFormatter = new ObjectStateFormatter();
                            try
                            {
                                tokenField = stateFormatter.Deserialize(context.Request.Form[CsrfSettings.Settings.FormFieldName]) as string;
                            }
                            catch
                            {
                                RaiseError(new PotentialCsrfException(Properties.Resources.exceptionMessageDeserializationError), context);
                            }

                            if (csrfCookie.Value != tokenField)
                            {
                                RaiseError(new PotentialCsrfException(Properties.Resources.exceptionMessageNotMatched), context);
                            }
                        }
                    }
                }
            }
        }

        /// <summary>
        /// Occurs when the server control is about to render to its containing <see cref="Page"/> control. 
        /// </summary>
        /// <param name="source">The event source.</param>
        /// <param name="eventArgs">The <see cref="System.EventArgs"/> instance containing the event data.</param>
        private static void PagePreRender(object source, EventArgs eventArgs)
        {
            Page page = source as Page;
            
            // Check we actually have a form on the page.
            if (page != null && page.Form != null)
            {
                string csrfToken;
                HttpContext context = HttpContext.Current;

                if (context.Request == null ||
                    context.Request.Cookies == null || 
                    context.Request.Cookies[CsrfSettings.Settings.CookieName] == null ||
                    string.IsNullOrEmpty(context.Request.Cookies[CsrfSettings.Settings.CookieName].Value))
                {
                    // No cookie yet so this must be a new "session", create one a new CSRF token so it gets set later
                    csrfToken = Guid.NewGuid().ToString("D", CultureInfo.InvariantCulture);
                    context.Items[ContextIndexName] = csrfToken;
                }
                else
                {
                    // Otherwise pull the token value from the cookie and check it's a GUID.
                    csrfToken = page.Request.Cookies[CsrfSettings.Settings.CookieName].Value;
                    Guid test;
                    bool isGuid = GuidTryParse(csrfToken, out test);
                    if (!isGuid)
                    {
                        RaiseError(
                            new PotentialCsrfException(Properties.Resources.exceptionMessageCoookieBadFormat),
                            context);
                    }
                }

                // Serialize the token value and add the hidden form field.
                ObjectStateFormatter stateFormatter = new ObjectStateFormatter();
                page.ClientScript.RegisterHiddenField(CsrfSettings.Settings.FormFieldName, stateFormatter.Serialize(csrfToken));
            }
        }

        /// <summary>
        /// Check whether the page is an ASP.NET PostBack which uses the GET method. If so, throw an exception.
        /// </summary>
        /// <param name="source">The event source.</param>
        /// <param name="eventArgs">The <see cref="System.EventArgs"/> instance containing the event data.</param>
        private static void PageLoad(object source, EventArgs eventArgs)
        {
            Page page = (Page)source;

            // If the Page is a post-back, disallow GET
            if (page.IsPostBack && page.Request.HttpMethod.Equals("GET"))
            {
                RaiseError(new BadPostBackException(), HttpContext.Current);
            }
        }

        /// <summary>
        /// Occurs before the request pipeline sends headers.
        /// </summary>
        /// <param name="source">The event source.</param>
        /// <param name="eventArgs">The <see cref="System.EventArgs"/> instance containing the event data.</param>
        private static void PreSendRequestHeaders(object source, EventArgs eventArgs)
        {
            HttpApplication application = (HttpApplication)source;
            HttpContext context = application.Context;

            // If the context has a value at this index, it means there is no cookie yet and we need to set it
            if (context.Items[ContextIndexName] != null)
            {
                WriteCsrfCookie(context.Response.Cookies, context.Items[ContextIndexName].ToString());
            }
        }

        /// <summary>
        /// Adds the CRSF protection cookie to the specified cookie collection.
        /// </summary>
        /// <param name="cookies">The cookie collection to add the cookie to.</param>
        /// <param name="value">The value for the created cookie.</param>
        private static void WriteCsrfCookie(HttpCookieCollection cookies, string value)
        {
            HttpCookie csrfCookie = new HttpCookie(CsrfSettings.Settings.CookieName)
            {
                Value = value,
                HttpOnly = true
            };
            cookies.Add(csrfCookie);
        }

        /// <summary>
        /// Raises a CSRF error based on the configuration settings.
        /// </summary>
        /// <param name="ex">The exception to throw if exceptions are configured.</param>
        /// <param name="context">The current HttpContext.</param>
        private static void RaiseError(Exception ex, HttpContext context)
        {
            if (CsrfSettings.Settings.DetectionResult == DetectionResult.Redirect && string.IsNullOrEmpty(CsrfSettings.Settings.ErrorPage))
            {
                throw new NoErrorPageSpecifiedException();
            }
            
            if (CsrfSettings.Settings.DetectionResult == DetectionResult.Redirect)
            {
                context.Response.Redirect(CsrfSettings.Settings.ErrorPage, true);
            }
            else
            {
                // Whilst this does lose the stack, that's not really a bad thing here as the exception message is detailed enough.
                throw ex;
            }
        }

        /// <summary>
        /// Attemps to converts the string representation of a Guid to its Guid
        /// equivalent. A return value indicates whether the operation
        /// succeeded.
        /// </summary>
        /// <param name="s">A string containing a Guid to convert.</param>
        /// <param name="result">
        /// When this method returns, contains the Guid value equivalent to
        /// the Guid contained in <paramref name="s"/>, if the conversion
        /// succeeded, or <see cref="Guid.Empty"/> if the conversion failed.
        /// </param>
        /// <returns>
        /// <see langword="true" /> if <paramref name="s"/> was converted
        /// successfully; otherwise, <see langword="false" />.
        /// </returns>
        private static bool GuidTryParse(string s, out Guid result)
        {
            result = Guid.Empty;

            if (s == null)
            {
                return false;
            }

            Regex format = new Regex(
                "^[A-Fa-f0-9]{32}$|" +
                "^({|\\()?[A-Fa-f0-9]{8}-([A-Fa-f0-9]{4}-){3}[A-Fa-f0-9]{12}(}|\\))?$|" +
                "^({)?[0xA-Fa-f0-9]{3,10}(, {0,1}[0xA-Fa-f0-9]{3,6}){2}, {0,1}({)([0xA-Fa-f0-9]{3,4}, {0,1}){7}[0xA-Fa-f0-9]{3,4}(}})$");
            Match match = format.Match(s);

            if (match.Success)
            {
                result = new Guid(s);
                return true;
            }

            return false;
        }
    }
}
